// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'dart:io' as io;

import 'package:analyzer/dart/analysis/features.dart';
import 'package:analyzer/file_system/file_system.dart';
import 'package:analyzer/src/dart/analysis/analysis_options.dart';
import 'package:analyzer/src/dart/analysis/experiments.dart';
import 'package:analyzer/src/util/file_paths.dart' as file_paths;
import 'package:analyzer/src/util/sdk.dart';
import 'package:analyzer_cli/src/ansi.dart' as ansi;
import 'package:analyzer_cli/src/driver.dart';
import 'package:args/args.dart';

const _analysisOptionsFileOption = 'options';
const _binaryName = 'dartanalyzer';
const _defineVariableOption = 'D';
const _enableExperimentOption = 'enable-experiment';
const _enableInitializingFormalAccessFlag = 'initializing-formal-access';
const _ignoreUnrecognizedFlagsFlag = 'ignore-unrecognized-flags';
const _packagesOption = 'packages';
const _sdkPathOption = 'dart-sdk';

/// Shared exit handler.
///
/// *Visible for testing.*
ExitHandler exitHandler = io.exit;

/// Print the given [message] to stderr and exit with the given [exitCode].
void printAndFail(String message, {int exitCode = 15}) {
  errorSink.writeln(message);
  exitHandler(exitCode);
}

/// Exit handler.
///
/// *Visible for testing.*
typedef ExitHandler = void Function(int code);

/// Analyzer commandline configuration options.
class CommandLineOptions {
  final ArgResults _argResults;

  /// The file path of the analysis options file that should be used in place of
  /// any file in the root directory or a parent of the root directory,
  /// or `null` if the normal lookup mechanism should be used.
  String? defaultAnalysisOptionsPath;

  /// The file path of the .packages file that should be used in place of any
  /// file found using the normal (Package Specification DEP) lookup mechanism,
  /// or `null` if the normal lookup mechanism should be used.
  String? defaultPackagesPath;

  /// A table mapping variable names to values for the declared variables.
  final Map<String, String> declaredVariables = {};

  /// The path to the dart SDK.
  String? dartSdkPath;

  /// Whether to disable cache flushing. This option can improve analysis
  /// speed at the expense of memory usage. It may also be useful for working
  /// around bugs.
  final bool disableCacheFlushing;

  /// Whether to display version information
  final bool displayVersion;

  /// Whether to ignore unrecognized flags
  final bool ignoreUnrecognizedFlags;

  /// Whether to log additional analysis messages and exceptions
  final bool log;

  /// Whether to use 'json' format for error display
  final bool jsonFormat;

  /// Whether to use 'machine' format for error display
  final bool machineFormat;

  /// The path to a file to write a performance log.
  /// (Or null if not enabled.)
  final String? perfReport;

  /// Batch mode (for unit testing)
  final bool batchMode;

  /// The source files to analyze
  final List<String> sourceFiles;

  /// Emit output in a verbose mode.
  final bool verbose;

  /// Use ANSI color codes for output.
  final bool color;

  /// Whether we should analyze the given source for the purposes of training a
  /// Dart analyzer snapshot.
  final bool trainSnapshot;

  /// Initialize options from the given parsed [args].
  CommandLineOptions._fromArgs(
    ResourceProvider resourceProvider,
    ArgResults args,
  ) : _argResults = args,
      dartSdkPath = args.option(_sdkPathOption),
      disableCacheFlushing = args.flag('disable-cache-flushing'),
      displayVersion = args.flag('version'),
      ignoreUnrecognizedFlags = args.flag(_ignoreUnrecognizedFlagsFlag),
      log = args.flag('log'),
      jsonFormat = args['format'] == 'json',
      machineFormat = args['format'] == 'machine',
      perfReport = args.option('x-perf-report'),
      batchMode = args.flag('batch'),
      sourceFiles = args.rest,
      trainSnapshot = args.flag('train-snapshot'),
      verbose = args.flag('verbose'),
      color = args.flag('color') {
    //
    // File locations.
    //
    defaultAnalysisOptionsPath = _absoluteNormalizedPath(
      resourceProvider,
      args.option(_analysisOptionsFileOption),
    );
    defaultPackagesPath = _absoluteNormalizedPath(
      resourceProvider,
      args.option(_packagesOption),
    );

    //
    // Declared variables.
    //
    var variables = (args[_defineVariableOption] as List).cast<String>();
    for (var variable in variables) {
      var index = variable.indexOf('=');
      if (index < 0) {
        // TODO(brianwilkerson): Decide the semantics we want in this case.
        // The VM prints "No value given to -D option", then tries to load '-Dfoo'
        // as a file and dies. Unless there was nothing after the '-D', in which
        // case it prints the warning and ignores the option.
      } else {
        var name = variable.substring(0, index);
        if (name.isNotEmpty) {
          // TODO(brianwilkerson): Decide the semantics we want in the case where
          // there is no name. If there is no name, the VM tries to load a file
          // named '-D' and dies.
          declaredVariables[name] = variable.substring(index + 1);
        }
      }
    }
  }

  /// A list of the names of the experiments that are to be enabled.
  List<String> get enabledExperiments {
    return _argResults.multiOption(_enableExperimentOption);
  }

  /// Update the [analysisOptions] with flags that the user specified
  /// explicitly. The [analysisOptions] are usually loaded from one of
  /// `analysis_options.yaml` files, possibly with includes. We consider
  /// flags that the user specified as command line options more important,
  /// so override the corresponding options.
  void updateAnalysisOptions(AnalysisOptionsImpl analysisOptions) {
    if (enabledExperiments.isNotEmpty) {
      analysisOptions.contextFeatures =
          FeatureSet.fromEnableFlags2(
                sdkLanguageVersion: ExperimentStatus.currentVersion,
                flags: enabledExperiments,
              )
              as ExperimentStatus;
    }
  }

  /// Return a list of command-line arguments containing all of the given [args]
  /// that are defined by the given [parser]. An argument is considered to be
  /// defined by the parser if
  /// - it starts with '--' and the rest of the argument (minus any value
  ///   introduced by '=') is the name of a known option,
  /// - it starts with '-' and the rest of the argument (minus any value
  ///   introduced by '=') is the name of a known abbreviation, or
  /// - it starts with something other than '--' or '-'.
  ///
  /// This function allows command-line tools to implement the
  /// '--ignore-unrecognized-flags' option.
  static List<String> filterUnknownArguments(
    List<String> args,
    ArgParser parser,
  ) {
    var knownOptions = <String>{};
    var knownAbbreviations = <String>{};
    parser.options.forEach((String name, Option option) {
      knownOptions.add(name);
      var abbreviation = option.abbr;
      if (abbreviation != null) {
        knownAbbreviations.add(abbreviation);
      }
      if (option.negatable ?? false) {
        knownOptions.add('no-$name');
      }
    });
    String optionName(int prefixLength, String argument) {
      var equalsOffset = argument.lastIndexOf('=');
      if (equalsOffset < 0) {
        return argument.substring(prefixLength);
      }
      return argument.substring(prefixLength, equalsOffset);
    }

    var filtered = <String>[];
    for (var i = 0; i < args.length; i++) {
      var argument = args[i];
      if (argument.startsWith('--') && argument.length > 2) {
        if (knownOptions.contains(optionName(2, argument))) {
          filtered.add(argument);
        }
      } else if (argument.startsWith('-D') && argument.indexOf('=') > 0) {
        filtered.add(argument);
      }
      if (argument.startsWith('-') && argument.length > 1) {
        if (knownAbbreviations.contains(optionName(1, argument))) {
          filtered.add(argument);
        }
      } else {
        filtered.add(argument);
      }
    }
    return filtered;
  }

  /// Parse [args] into [CommandLineOptions] describing the specified
  /// analyzer options. In case of a format error, calls [printAndFail], which
  /// by default prints an error message to stderr and exits.
  static CommandLineOptions? parse(
    ResourceProvider resourceProvider,
    List<String> args, {
    void Function(String msg) printAndFail = printAndFail,
  }) {
    var options = _parse(resourceProvider, args);

    /// Only happens in testing.
    if (options == null) {
      return null;
    }

    // Check SDK.
    {
      var sdkPath = options.dartSdkPath;

      // Check that SDK is existing directory.
      if (sdkPath != null) {
        if (!io.Directory(sdkPath).existsSync()) {
          printAndFail('Invalid Dart SDK path: $sdkPath');
          return null; // Only reachable in testing.
        }
      }

      // Infer if unspecified.
      sdkPath ??= getSdkPath();

      var pathContext = resourceProvider.pathContext;
      options.dartSdkPath = file_paths.absoluteNormalized(pathContext, sdkPath);
    }

    return options;
  }

  static String? _absoluteNormalizedPath(
    ResourceProvider resourceProvider,
    String? path,
  ) {
    if (path == null) {
      return null;
    }
    var pathContext = resourceProvider.pathContext;
    return pathContext.normalize(pathContext.absolute(path));
  }

  /// Add the standard flags and options to the given [parser]. The standard flags
  /// are those that are typically used to control the way in which the code is
  /// analyzed.
  ///
  // TODO(danrubel): Update DDC to support all the options defined in this method
  // then remove the [ddc] named argument from this method.
  static void _defineAnalysisArguments(
    ArgParser parser, {
    bool hide = true,
    bool ddc = false,
  }) {
    parser.addOption(
      _sdkPathOption,
      help: 'The path to the Dart SDK.',
      hide: ddc && hide,
    );
    parser.addOption(
      _analysisOptionsFileOption,
      help: 'Path to an analysis options file.',
      hide: ddc && hide,
    );
    parser.addMultiOption(
      _enableExperimentOption,
      help:
          'Enable one or more experimental features. If multiple features '
          'are being added, they should be comma separated.',
      splitCommas: true,
    );

    //
    // Hidden flags and options.
    //
    parser.addMultiOption(
      _defineVariableOption,
      abbr: 'D',
      help:
          'Define an environment declaration. For example, "-Dfoo=bar" defines '
          'an environment declaration named "foo" whose value is "bar".',
      hide: hide,
    );
    parser.addOption(
      _packagesOption,
      help:
          'The path to the package resolution configuration file, which '
          'supplies a mapping of package names\ninto paths.',
      hide: ddc,
    );
    parser.addFlag(
      _enableInitializingFormalAccessFlag,
      help:
          'Enable support for allowing access to field formal parameters in a '
          'constructor\'s initializer list (deprecated).',
      defaultsTo: false,
      negatable: false,
      hide: hide || ddc,
    );
  }

  static String _getVersion() {
    try {
      // This is relative to bin/snapshot, so ../..
      var versionPath = io.Platform.script
          .resolve('../../version')
          .toFilePath();
      var versionFile = io.File(versionPath);
      return versionFile.readAsStringSync().trim();
    } catch (_) {
      // This happens when the script is not running in the context of an SDK.
      return '<unknown>';
    }
  }

  static CommandLineOptions? _parse(
    ResourceProvider resourceProvider,
    List<String> args,
  ) {
    var verbose = args.contains('-v') || args.contains('--verbose');
    var hide = !verbose;

    var parser = ArgParser(allowTrailingOptions: true);

    if (!hide) {
      parser.addSeparator('General options:');
    }

    // TODO(devoncarew): This defines some hidden flags, which would be better
    // defined with the rest of the hidden flags below (to group well with the
    // other flags).
    _defineAnalysisArguments(parser, hide: hide);

    parser
      ..addOption(
        'format',
        help:
            'Specifies the format in which errors are displayed; the only '
            'currently recognized values are \'json\' and \'machine\'.',
      )
      ..addFlag(
        'version',
        help: 'Print the analyzer version.',
        defaultsTo: false,
        negatable: false,
      )
      ..addFlag(
        'help',
        abbr: 'h',
        help:
            'Display this help message. Add --verbose to show hidden options.',
        defaultsTo: false,
        negatable: false,
      )
      ..addFlag(
        'verbose',
        abbr: 'v',
        defaultsTo: false,
        help: 'Verbose output.',
        negatable: false,
      );

    parser.addFlag(
      'color',
      help: 'Use ansi colors when printing messages.',
      defaultsTo: ansi.terminalSupportsAnsi(),
      hide: hide,
    );

    // Hidden flags.
    if (!hide) {
      parser.addSeparator('Less frequently used flags:');
    }

    parser
      ..addFlag(
        'batch',
        help: 'Read commands from standard input (for testing).',
        defaultsTo: false,
        negatable: false,
        hide: hide,
      )
      ..addFlag(
        _ignoreUnrecognizedFlagsFlag,
        help: 'Ignore unrecognized command line flags.',
        defaultsTo: false,
        negatable: false,
        hide: hide,
      )
      ..addFlag('disable-cache-flushing', defaultsTo: false, hide: hide)
      ..addOption(
        'x-perf-report',
        help: 'Writes a performance report to the given file (experimental).',
        hide: hide,
      )
      ..addFlag(
        'enable-conditional-directives',
        help:
            'deprecated -- Enable support for conditional directives (DEP 40).',
        defaultsTo: false,
        negatable: false,
        hide: hide,
      )
      ..addFlag(
        'log',
        help: 'Log additional messages and exceptions.',
        defaultsTo: false,
        negatable: false,
        hide: hide,
      )
      ..addFlag(
        'use-analysis-driver-memory-byte-store',
        help: 'Use memory byte store, not the file system cache.',
        defaultsTo: false,
        negatable: false,
        hide: hide,
      )
      ..addMultiOption(
        'url-mapping',
        help:
            '--url-mapping=libraryUri,/path/to/library.dart directs the '
            'analyzer to use "library.dart" as the source for an import '
            'of "libraryUri".',
        splitCommas: false,
        hide: hide,
      )
      ..addFlag(
        'train-snapshot',
        help:
            'Analyze the given source for the purposes of training a '
            'dartanalyzer snapshot.',
        hide: hide,
        negatable: false,
      );

    try {
      if (args.contains('--$_ignoreUnrecognizedFlagsFlag')) {
        args = filterUnknownArguments(args, parser);
      }
      var results = parser.parse(args);

      // Help requests.
      if (results.flag('help')) {
        _showUsage(parser, fromHelp: true);
        exitHandler(0);
        return null; // Only reachable in testing.
      }

      // Batch mode and input files.
      if (results.flag('batch')) {
        if (results.rest.isNotEmpty) {
          errorSink.writeln('No source files expected in the batch mode.');
          _showUsage(parser);
          exitHandler(15);
          return null; // Only reachable in testing.
        }
      } else if (results.flag('version')) {
        outSink.writeln('$_binaryName version ${_getVersion()}');
        exitHandler(0);
        return null; // Only reachable in testing.
      } else {
        if (results.rest.isEmpty) {
          _showUsage(parser, fromHelp: true);
          exitHandler(15);
          return null; // Only reachable in testing.
        }
      }

      if (results.wasParsed(_enableExperimentOption)) {
        var names = results.multiOption(_enableExperimentOption);
        var errorFound = false;
        for (var validationResult in validateFlags(names)) {
          if (validationResult.isError) {
            errorFound = true;
          }
          var kind = validationResult.isError ? 'ERROR' : 'WARNING';
          errorSink.writeln('$kind: ${validationResult.message}');
        }
        if (errorFound) {
          _showUsage(parser);
          exitHandler(15);
          return null; // Only reachable in testing.
        }
      }

      return CommandLineOptions._fromArgs(resourceProvider, results);
    } on FormatException catch (e) {
      errorSink.writeln(e.message);
      _showUsage(parser);
      exitHandler(15);
      return null; // Only reachable in testing.
    }
  }

  static void _showUsage(ArgParser parser, {bool fromHelp = false}) {
    errorSink.writeln(
      'Usage: $_binaryName [options...] <directory or list of files>',
    );

    errorSink.writeln('');
    errorSink.writeln(parser.usage);

    errorSink.writeln('');
    errorSink.writeln('''
Run "dartanalyzer -h -v" for verbose help output, including less commonly used options.
For more information, see https://dart.dev/tools/dartanalyzer.\n''');
  }
}
